AOP 在 Android 开发中的应用

2022-09-25

一、前言

1. 什么是 AOP ?

AOP 是 Aspect-oriented programming 的缩写,即面向切面编程,是一种编程范式。通过它可以在不修改原有代码的情况下向现有代码中添加某些行为。例如:当函数名称以 “set” 开头时,记录所有函数的调用。这允许将非业务逻辑的核心行为添加到程序中,而不会使代码与原有业务逻辑耦合在一起。

2. Android 项目中如何实现 AOP ?

Android 官方使用 Gradle 作为应用的构建工具,与其强大的可扩展性密不可分。Android 官方依赖 Gradle 提供了一个 com.android.tools.build:gradle:$version plugin,这个 Plugin 想必大家都不陌生,它就是 AGP 。AGP 在 1.5.0-beta1 的时候添加了 Transform API ,支持第三方 plugins 在将编译好的 class 文件转换为 dex 文件之前对其进行操作。

熟悉 Android 打包流程的应该都看过下面这张图:

典型 Android 应用模块的构建流程

Transform API 作用时机就是在 Compilers -> DEX File(s) 这个过程中。

Transform API 工作流程如下:transform-process

即上一个 Transform 的输出作为下一个 Transform 的输入。我们可以通过自定义 Plugin 注册 Transform API 来获取上一个 Transform 的输入,然后按照自己的需求,对相应的 class 文件做修改,然后交给下一个 Transform 作为输入继续后面的流程。

而修改 class 文件需要借助字节码操作框架来实现,目前主流的字节码操作框架有如下几种:ASM,Javassist,AspectJ,ByteBuddy,Lancet 等。ByteBuddy 目前并未使用过,暂不做评价。Lancet 是 eleme 开源的一款轻量级的 Android 平台的字节码操作框架。原理也是基于 AGP Transform API 扫描 class 文件,底层使用 ASM 实现插桩,使用方式上类似 AspectJ,不过 Lancet 目前处于无人维护的状态,所使用 AGP 版本比较旧(AGP 3.3.2),如果想使用,需要自己 fork 升级 AGP 版本并解决升级过程的错误,其实可以考虑暂不做考虑。另外值得一提的是,ReDex 是 Fackbook 开源的一个针对 Android Dex 文件优化的一个库,使用 C++ 实现,它提供了一系列指令生成 API 和 Opcode 插入 API,我们可以借助它实现自己的字节码注入工具,但是其上手难度很高,暂不考虑。

二、ASM, Javassist, AspectJ

1. ASM

ASM是一个通用的 Java 字节码操作和分析框架。它可以用来修改现有的类或直接以二进制形式动态生成类。ASM 提供了一些常见的字节码转换和分析算法,从中可以构建定制的复杂转换和代码分析工具。ASM 提供了与其他 Java 字节码框架相似的功能,但重点是性能。由于它被设计和实现得尽可能小、尽可能快,因此非常适合在动态系统中使用(当然也可以以静态方式使用,例如在编译器中)。

ASM用于许多项目,包括:

  • OpenJDK,以生成 lambda 调用位置,也可以在 Nashorn 编译器中生成;
  • Groovy 编译器和 Kotlin 编译器;
  • Cobertura 和 Jacoco,为了测量代码覆盖率;
  • Byte Buddy,用于动态生成类,它本身用于其他项目,如 Mockito(用于生成模拟类);
  • Gradle,在运行时生成一些类。

优缺点:

  • 操作灵活。支持任意字节码的操作。支持 visitor api 和 tree api 两种方式;
  • 高性能;
  • 学习曲线陡峭。ASM 是非常底层的面向字节码编程的 AOP 框架。需要掌握 Java 字节码的相关知识才能使用。

2. Javassist

Javassist(Java Programming Assistant)使 Java 字节码操作变得简单。它是一个用 Java 编辑字节码的类库;它使 Java 程序能够在运行时定义新类,并在 JVM 加载时修改类文件。与其他类似的字节码编辑器不同,Javassist 提供了两级 API:源代码级和字节码级。如果用户使用源代码级 API,他们可以在不了解 Java 字节码规范的情况下编辑类文件。整个 API 仅使用 Java 语言的词汇表进行设计。您甚至可以以源文本的形式指定插入的字节码;Javassist 实时编译它。另一方面,字节码级 API 允许用户像其他编辑器一样直接编辑类文件。

优缺点:

  • 易上手。提供了高封装度的 api,只需要了解很少的字节码知识就可以使用;
  • 性能低。使用到反射机制,性能比较低。

3. AspectJ

  • Java 编程语言的无缝面向切面的扩展;
  • 兼容 Java 平台;
  • 易于学习和使用;
  • 横切关注点的干净模块化,例如错误检查和处理、同步、上下文相关行为、性能优化、监视和日志记录、调试支持以及多对象协议。

优缺点:

  • 成熟稳定。一般不用考虑插入的字节码正确性的问题;
  • 易上手。使用者完全不需要理解任何 Java 字节码相关的知识,就可以使用自如。它可以在方法(含构造方法)被调用位置、方法体(含构造方法)内部、读写变量位置、静态代码块内部、异常处理位置等前后,插入自定义代码,或者直接将原位置的代码替换为自己的代码;
  • 切入点固定。只能在一些固定的切入点进行操作,如果想要更加细致的操作,则无法完成。不能针对一些特定规则的字节码序列做操作;
  • 正则表达式。AspectJ 使用正则表达式匹配规则,比如匹配 Activity 生命周期的 onXXX 方法,如果有自定义的其他的以 on 开头的方法也会被匹配到;
  • 性能较低。AspectJ 插入的代码会包装自己的一些类,逻辑比较复杂,不仅生成的字节码比较大,对原函数的性能也会造成一定的影响。

4. 对比

ASM Javassist AspectJ
易用性
性能
自由度

三、自定义 Gradle Plugin

介绍完 Transform API 的机制和各种 AOP 框架的优劣后,我们就可以选择适合我们的框架,编写插桩代码,然后将其集成到 Android 项目的构建流程中,实现构建时自动化插桩。

那么如何集成到 Gradle 的构建流程中呢?答案就是自定义 Gradle Plugin。

创建 Gradle Plugin 有以下三种方式:

  1. 在 gradle script 中创建。gradle script 中一般用来配置一些静态配置,不建议使用这种方式创建 plugin;
  2. buildSrc 模块中创建。一般用来创建项目内的 plugin,不用发布,可以在 script 中直接引用;
  3. 在独立的模块中创建。一般用来创建可发布到公有仓库,供其他项目使用的 plugin。

本文示例使用第二种方式创建:

  1. 在项目根目录下创建一个名为 buildSrc 的目录,注意,1. 是创建目录,不是创建 module,2. buildSrc 大小写要一致;

  2. 在目录中创建 build.gradle.kts 脚本文件,如果是使用 java 编写 plugin,就引入 java-library 插件,如果是用 groovy 编写,就引入 groovy 插件,如果是用 kotlin 编写,就引入 kotlin("jvm") 插件,我这里使用 kotlin 来编写插件;然后在 dependencies block 中添加 gradleApi()。如果只是创建一个 Gradle Plugin,到这里 build.gradle.kts 其实就已经配置好了;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    plugins {
    // `java-library`
    // groovy
    kotlin("jvm") version "1.7.10"
    }

    repositories {
    google()
    mavenCentral()
    }

    dependencies {
    implementation(kotlin("stdlib"))
    gradleApi()
    }
  3. 创建 src/main/kotlin 目录,再创建自定义的 Plugin 类,实现自 org.gradle.api.Plugin 接口:

    1
    2
    3
    4
    5
    6
    7
    8
    import org.gradle.api.Plugin
    import org.gradle.api.Project

    class SamplePlugin : Plugin<Project> {
    override fun apply(target: Project) {
    println("SamplePlugin>>apply")
    }
    }
  4. 在 application 或 library module 中 apply plugin:

    1
    2
    // app module's build.gradle.kts
    apply<SamplePlugin>()
  5. sync project:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    > Task :buildSrc:compileKotlin UP-TO-DATE
    > Task :buildSrc:compileJava NO-SOURCE
    > Task :buildSrc:compileGroovy NO-SOURCE
    > Task :buildSrc:processResources NO-SOURCE
    > Task :buildSrc:classes UP-TO-DATE
    > Task :buildSrc:inspectClassesForKotlinIC UP-TO-DATE
    > Task :buildSrc:jar UP-TO-DATE
    > Task :buildSrc:assemble UP-TO-DATE
    > Task :buildSrc:compileTestKotlin NO-SOURCE
    > Task :buildSrc:compileTestJava NO-SOURCE
    > Task :buildSrc:compileTestGroovy NO-SOURCE
    > Task :buildSrc:processTestResources NO-SOURCE
    > Task :buildSrc:testClasses UP-TO-DATE
    > Task :buildSrc:test NO-SOURCE
    > Task :buildSrc:check UP-TO-DATE
    > Task :buildSrc:build UP-TO-DATE

    > Configure project :app
    SamplePlugin>>apply

    > Task :prepareKotlinBuildScriptModel UP-TO-DATE

    BUILD SUCCESSFUL in 1s
    3 actionable tasks: 3 up-to-date
    > Task :prepareKotlinBuildScriptModel UP-TO-DATE

    BUILD SUCCESSFUL in 105ms

顺便说一下这里的 UP-TO-DATE:Gradle 之所以能够在 Project 上工作,都是由一个个的 Task 所支持的。Task 表示构建执行的一些基本操作。例如创建 Jar,生成 Javadoc 或者发布一些 Archives 到仓库。而大多数 Task 都声明了输入和输出,Gradle 根据检查 Task 的输入和输出来确定该 Task 是否是 UP-TO-DATE 的。

例如:compile task 的输入是 source code,如果 source code 自从上次 compile 以来没有任何变化,然后会检查输出,确保编译器生成的 class 文件没有被破坏,如果输入和输出都没有任何变化,Gradle 认为这个 Task 是 UP-TO-DATE 的,这使得在构建项目时可以节省大量的时间。

我们可以看到 build 控制台中已经输出了我们在 Plugin 中打印的内容了。到这里,一个完整的 Plugin 创建步骤就完成了,这是一个只依赖于 Gradle Api 的 Plugin,我们可以在里面创建 Task 等。但是,因为我们要借助 AGP 来开发我们的 AOP Plugin,所以,我们还需要在 buildSrc build.gradle.kts 中添加 AGP 和相关 AOP 框架的依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dependencies {
implementation(kotlin("stdlib"))
// gradle api, agp, kgp
gradleApi()
implementation("com.android.tools.build:gradle:7.3.0")
implementation("com.android.tools.build:gradle-api:7.3.0")
implementation(kotlin("gradle-plugin", "1.7.10"))
// asm
implementation("org.ow2.asm:asm-tree:9.2")
implementation("org.ow2.asm:asm-util:9.2")
implementation("org.ow2.asm:asm-commons:9.2")
// javassist
implementation("org.javassist:javassist:3.29.2-GA")
// aspectj
implementation("org.aspectj:aspectjtools:1.9.7")
}

四、AGP Transform API

AGP Transform API 在 AGP 7.0 的时候被标记为废弃,预计在 AGP 8.0 中会被删除。取而代之的是 Gradle 提供的 TransformAction 以及 AGP 在新版本中提供的相关 API。

那我们为什么还要学它呢?

  1. 社区沉淀:AGP Transform API 已经发展多年,社区中已经沉淀了大量优秀的开源项目以及博客文章;
  2. 思维方式:虽然 API 已过时,但学习其中的思路,对理解 Gradle TransformAction 有一定的帮助。

优秀的涉及到 AGP Transform API 相关的开源项目:Lancet、StringFog、一些插件化/组件化/路由框架

优秀的涉及到 Gradle TransformAction 相关的开源项目:BRouter

  • 获取 BaseExtension
1
2
3
4
5
// def android = project.android
// def android = project.extensions.android
// def android = project.extensions.getByType(AppExtension)
// val android = project.extensions.getByName("android") as BaseExtension
val android = project.extensions.getByType(BaseExtension::class.java)
  • registerTransform
1
android.registerTransform(XXXTransform())
  • Transform

Transform 是用来处理构建过程中的中间产物。每添加一个 Transform,都会对应创建一个 Task,该 Task 的命名规则为 transform${inputTypes}With${name}For${variant}

String getName():指定 Transform 的名称

Set<ContentType> getInputTypes():返回当前 Transform 处理的数据的类型

Set<? super Scope> getScopes():返回当前 Transform 处理的数据的范围

boolean isIncremental():指定当前 Transform 是否开启增量编译,如果开启的话,需要自己在 transform 时处理好增量的逻辑

void transform(@NonNull TransformInvocation transformInvocation):在该方法中实现字节码插桩操作

TransformInvocation 中包含了所有的输入和输出信息,Transform 的输入是一个 TransformInput 的集合,TransformInput 中又包含了 JarInput 的集合和 DirectoryInput 的集合,这两者都提供了具体的输入内容,以及与其关联的 ContentTypeScope 信息。

Transform 的输出通过 TransformOutputProvider 获取。

Transform 的输入和输出均由 AGP 内部处理,并且它们的位置不可配置。

⚠️ 重要提示:即使我们不想 transform 任何东西,我们也需要将所有的输入 copy 到输出,否则最终生成的 APK 中不会包含这些文件。

transforms

直接看代码吧:https://github.com/porum/AgpBytecodeManipulationDemo

五、Variant/Artifact API

六、总结

AOP 可以降低项目的耦合度,对项目源代码无入侵,提高开发效率,但是需要掌握一定的字节码相关的知识,以及字节码插桩框架的使用,一定程度上限制了人们对其了解的冲动。还是希望感兴趣的可以行动起来,迈开第一步,就会打开新世界的大门。

七、参考

  1. https://asm.ow2.io/
  2. http://www.javassist.org/
  3. https://www.eclipse.org/aspectj/doc/released/progguide/index.html
  4. https://developer.android.google.cn/studio/releases/gradle-plugin-api-updates#transform-removed
  5. http://tools.android.com/tech-docs/new-build-system/transform-api
  6. https://developer.android.google.cn/studio/build#build-process
  7. https://source.android.google.cn/docs/core/runtime/dalvik-bytecode
  8. https://medium.com/grandcentrix/transform-api-a-real-world-example-cfd49990d3e1
  9. https://rebooters.github.io/2020/01/04/Gradle-Transform-ASM-%E6%8E%A2%E7%B4%A2/
  10. https://segmentfault.com/a/1190000041861621